"""
电影院排队simpy仿真
关键：
单服务台
顾客只会因为自己想买的电影没有售完才进入排队
排队的顾客只会因为想买的电影售完而中途退出排队

理论上讲，当标准 M/M/1 模型的 lambda / mu 大于 1 时，会造成
无限排队的问题。但是在此模型中，顾客会因为看见电影票售完而不会进入
排队。

更新记录：
    面向对象编程,TheaterSimulator电影院模拟器类, 
    queue_info排队结果信息类
    考虑了多服务台的情况
    在电影票销售逻辑中，多服务台的情况需要考虑如何避免票数超卖的问题。
    一种常见的解决方案是，不再是每个服务台每次服务一个顾客就检查一次票数，
    而是另安排一个员工每隔一段时间检查一次各个电影票的剩余数量，
    并进行相应的处理。
    这样可以确保即使在多个服务台同时售票的情况下，
    也不会出现电影票销量大于电影票总量的情况。   

"""

# In[0]:
#导入必要的工具
import random
import simpy
import numpy as np
import pandas as pd
import seaborn as sns

# 数据可视化工具
import matplotlib.pyplot as plt
from pylab import mpl
# 指定默认字体：解决plot不能显示中文问题
mpl.rcParams[
    'font.sans-serif'
    ] = ['SimHei'] 
# 解决保存图像是负号'-'显示为方块的问题
mpl.rcParams[
    'axes.unicode_minus'
    ] = False 


#In[1]:
# 电影院参数
NUM_COUNTER = 1 # 售票柜台数
MOVIES_AVAILABLE = { # 电影院在映电影
    '云边有个洗车店'   : 50,
    '天气の爷'        : 75,
    '熊出没之流浪地球' : 80,
    '芭比海默'        : 70,
    '半池绿'         : 100,
    '李焕英：纵横宇宙' : 40
    }

SERVICE_TIME = 60 # 服务一位顾客的平均时间

# 顾客参数
INTERVAL_CUSTOMERS = 30 # 顾客到达的平均时间

# 其他参数
RANDOM_SEED = 42 # 随机种子
SIM_TIME = 5000 # 模拟时间

#In[2]:
#定义电影院模拟器类
class TheaterSimulator :
    """ 电影院模拟器类                                                                                                                                  
    """
    def __init__(
            self, env, 
            num_counter, # 电影院的售票柜台总数
            movies_available,
            interval     # 平均顾客到访的时间
        ) :
        """ 初始化类
        """

        """ 初始化环境
        """
        self.env = env
        self.customer_info = [
            [], # 保存顾客编号
            [], # 顾客想买的电影
            [], # 保存顾客等待时间
            [], # 记录电影院是否剩有顾客想要的电影
            [], # 记录电影剩余票数是否满足顾客需求
        ] 
        self.theater_status = [
            [], # 时间戳
            [], # 各个时间戳下的队列长度
        ] 


        """ 所有电影的名称
            
            这是最关键的一个字典，后面很多的字典创建
            还有过程处理，都依赖 self.movies
        """
        self.movies = list(
            movies_available.keys()
            )
        """ 电影院的属性字典
        """
        self.theater_num_not_entered = {
                movie: 0 for movie in self.movies
            } # 进入电影院但是没有进入排队的顾客
        
        """ 电影票的属性字典

            之所以要创建 movie_sold_out 字典，
            是因为在这段代码中，
            movie_sold_out 是通过 
            
            simpy.Environment.event.succeed()

            这个方法来实现的。movie_sold_out 
            字典实际上保存的是一组 event 实例对象。
        """
        self.movie_available =  movies_available
        self.movie_sold_out  = {
                movie: self.env.event() for movie in self.movies
            } # 每部电影的票数是否售完
        self.movie_sold_out_time = {
                movie: None for movie in self.movies
            } # 每部电影售完时间
        self.movie_num_renegers = {
                movie: 0 for movie in self.movies
            } # 每部电影放弃排队的人
        
        """ 服务台对象
        """
        self.counter = simpy.Resource( # 电影院柜台
                env, 
                capacity=num_counter) 

        """ 创建进程
        """
        self.env.process(
            self.record_theater_status()
        )
        self.env.process(
            self.customer_arrivals(interval) 
        )
        self.env.process(
            self.check_movie_conditions()
        )
        
    def record_theater_status(self):
        """ 记录电影院的状态
        """
        while True:
            self.theater_status[0].append(self.env.now)
            self.theater_status[1].append(
                len(self.counter.queue)
                )
            yield self.env.timeout(30)  # 每隔一定时间记录一次
    
    def check_movie_conditions(self):
        """ 检查各个电影的状态
            
            如果按照原本的设计，电影院只能有一个柜台。这是因为每接待一个顾客，
            柜台员就会检查电影票是否售完。如果当一个顾客购买电影票的时候，首
            先检查剩余的电影票数量，发现符合要求，进入了购买电影票的 timeout，
            另一个顾客出现，买走了这两张电影票，这个时候电影票被判断为售完，可
            是前一个顾客的购票进程尚未结束。在 timeout 计时结束之后，为这个
            计时过后的顾客记下了两张电影票的销量，就会出现电影票销量大于电影票
            总量的情况。这是不符合逻辑的。

            为允许修改服务台数量，修改代码的逻辑。从原来的每服务一位顾客检查一
            次电影票的数量，改为另安排一个员工每隔一段时间检查一次各个电影票的
            状态，就能很好地解决任意修改多服务台的问题。
        """
        while True:
            # 每隔 1 秒检查一次
            yield self.env.timeout(1)
            for movie in self.movies:
                # 检查电影的剩余票数是否小于 2
                if self.movie_available[movie] < 2\
                   and self.movie_sold_out_time[
                       movie
                    ]==None:
                    # 电影院售票状态为售完
                    self.movie_sold_out[movie].succeed()
                    # 记录电影售完时间
                    self.movie_sold_out_time[movie] = self.env.now
                    # 假如这个电影还剩下一张票
                    # 那么单独的一张票会自动作废 
                    # 仅当剩下两张票被一个顾客一次性买走的情况会被考虑
                    self.movie_available[movie] = 0
        
    def customer_arrivals(self, interval):
        """ 顾客到达（顾客源）
        """
        i = 1 # 
        while True:
            yield self.env.timeout(
                    max( # 限制最小时间间隔为 1 秒
                            1.0, 
                            random.expovariate(1.0 / interval)
                        )
                    )    
            movie = random.choice( # 随机在 self.movies 里选择
                    self.movies
                    ) # 顾客想买的电影
            num_tickets = random.randint(1, 6) # 顾客想买的电影票数

            """ 售票柜台的显示屏会显示每部电影是否售完
                如果该位顾客想买的电影的已售完
                则他不会进入排队系统
            """
            print(movie) # 打印顾客想看的电影
            print(self.movie_available[movie]) # 打印该电影的余票
            if self.movie_available[movie]:
                self.env.process(
                    self.moviegoer(
                        'customer %d' % i, 
                        movie,
                        num_tickets,
                        service_time = SERVICE_TIME
                    )
                )
            else:
                print(
                    'customer %d did not enter the queuing system' % i
                    )
                self.theater_num_not_entered[movie] += 1
            i = i + 1

    def moviegoer(
            self, name, movie, 
            num_tickets, service_time):
        """ 顾客进程

            顾客进入排队系统，离开排队系统的情况：

            1.排队过程中想买的电影票售完
            2.轮到该顾客，想买的电影的剩余票数满足或不满足他的需求
        """
        arrive = env.now # 记录一下顾客到达的时间
        print( # 打印顾客到达电影院的信息，以及当前时间
            '%s arriving at theater at %0.1f s' % (
                name, arrive )
        ) 

        with self.counter.request() as my_turn:  # 请求售票窗口的服务
        # 等待直到轮到我或者电影票售完
            result = yield my_turn | self.movie_sold_out[movie]
            wait = env.now - arrive
            self.customer_info[0].append(name)
            self.customer_info[1].append(movie)
            self.customer_info[2].append(wait)

            # 如果排队过程中电影票售完
            if my_turn not in result:
                print(
                        '%s sold out the movie tickets while waiting in line: Waited %0.1f s' % (
                            name,  wait)
                        )
                self.customer_info[3].append(False)
                self.customer_info[4].append(False)
                self.movie_num_renegers[movie] += 1
                # 记录该部电影放弃排队的人数
                return # 提前退出进程

            # 如果轮到我，剩余票是否满足顾客的要求
            self.customer_info[3].append(True)
            if self.movie_available[movie] < num_tickets: 
                self.customer_info[4].append(False)
                print(
                        '%s The remaining number of tickets of the movie does not meet the demand: Waited %0.1f s' % (
                    name, wait
                    )
                )
                yield self.env.timeout(
                    max(
                        1,
                        random.expovariate(1.0 / service_time)
                    )
                )
                print(
                '%s finished at %0.1d s' % (
                    name, self.env.now
                    )
                )
                return # 提前退出进程

            # 如果上述两种情况都没有发生，则满足买票
            print(
                '%s got some tickets: Waited %0.1f s' % (
                    name, wait
                )
            ) 
            self.movie_available[movie] -= num_tickets
            self.customer_info[4].append(True)
        
            """ 每接待一个顾客的时候，柜台员就会检查一下当前电影票的状态。
                当每部电影的剩余票数小于2时，该部电影处于售完状态
            """
            # 检查电影的剩余票数是否小于 2
            # if self.movie_available[movie] < 2:
            #     # 电影院售票状态为售完
            #     self.movie_sold_out[movie].succeed()
            #     # 记录电影售完时间
            #     self.movie_sold_out_time[movie] = self.env.now
            #     # 假如这个电影还剩下一张票
            #     # 那么单独的一张票会自动作废 
            #     # 仅当剩下两张票被一个顾客一次性买走的情况会被考虑
            #     self.movie_available[movie] = 0
    
            yield self.env.timeout( # 柜台的服务时间
                max(
                    1,
                    random.expovariate(1.0 / service_time)
                )
            )
            print(
                '%s finished at %0.1d s' % (
                    name, self.env.now
                )
            )

    def printMovieCondition(self):
        """ 电影情况：售完时间，放弃排队的人，未进入排队系统的人
        """
        for movie in self.movies:
            if self.movie_sold_out_time[movie]:
                print(
                    'Movie "%s" sold out %.1f s after ticket counter opening.' % (
                        movie, 
                        self.movie_sold_out_time[movie])
                )
                print(
                    ' Number of people leaving queue when film sold out: %s' % (
                        self.movie_num_renegers[movie]
                    )
                )
                print(
                    ' Number of people who did not get into the line when film sold out: %s' % (
                        self.theater_num_not_entered[movie]
                    )
                )
        
    def result(self):
        """ 输出结果
        """
        customer_info_df = pd.DataFrame([])
        customer_info_df['Customer'] =  self.customer_info[0]
        customer_info_df['wanted_movie'] =  self.customer_info[1]
        customer_info_df['wait_time'] =  self.customer_info[2]
        customer_info_df['is_unreneged'] = self.customer_info[3]
        customer_info_df['is_satisfied'] = self.customer_info[4]
        theater_status_df = pd.DataFrame(
                np.array(self.theater_status).T,
                columns=['time', 'queue_len']
                )
        return queue_info(
            customer_info_df, 
            theater_status_df) # queue_info类在下面定义
    
#In[3]:
# 定义排队结果类
class queue_info:
    """ 排队结果信息类
    """
    def __init__(
            self, 
            customer_info_df, 
            theater_status_df
            ):
        """ 类的初始化
        """
        self.customer_info_df =  customer_info_df
        self.theater_status_df = theater_status_df

    def info(self):
        """ 排队系统的参数信息
        """
        return self.customer_info_df, self.theater_status_df

    def print_info(self):
        """ 打印排队信息表
        """
        print(self.customer_info_df)
        print(self.theater_status_df)

    def plot_info(self, figsize=(8, 4), dpi=100):
        """ 画个图

            该方法将会绘制两张子图：

            1. 各个顾客的排队时间（用红色标记出 reneged，黄色标出 unsatisfied)
            2. 各个时间点上的队列长度
        """
        fig, axes = plt.subplots(
            2, 1,
            figsize=figsize,
            dpi=dpi)
        colors = []
        for x in self.customer_info_df['is_unreneged']:
            if x:
                colors.append('C0')
            else:
                colors.append('red')
       
        axes[0].bar(
            self.customer_info_df['Customer'],
            self.customer_info_df['wait_time'],
            color=colors)
        axes[0].set_title('wait time')
        axes[0].set_xticklabels([])
        axes[1].plot(
            self.theater_status_df['time'],
            self.theater_status_df['queue_len']
            )
        axes[1].set_title('queue length')
        fig.suptitle('Queue Result')
        fig.tight_layout()
        return fig, axes

#In[4]:
# 实例化仿真环境和仿真器
random.seed(RANDOM_SEED)
print('Theater Renege Simulation')
env = simpy.Environment()
simTheater = TheaterSimulator(
    env, 
    NUM_COUNTER, 
    MOVIES_AVAILABLE,
    INTERVAL_CUSTOMERS
    )

#In[5]:
# 初始化进程并运行仿真环境
env.run(until=SIM_TIME)

#In[6]:
# 提取仿真结果
result = simTheater.result()
result.print_info()
result.plot_info(dpi=300)

#In[7]:
# 各个电影的情况
simTheater.printMovieCondition()

# In[8]:
# 绘制统计分布图
fig, ax = plt.subplots(dpi=300)
sns.histplot(
    result.customer_info_df['wait_time'], 
    bins=10,
    kde=True,
    ax=ax, 
    label='waiting time (unreneged)' )
ax.legend()
ax.set_xlabel('等待时间')
ax.set_ylabel('客户数量')
fig.suptitle('未退出客户的等待时间分布')

# In[9]:
fig, ax = plt.subplots(figsize=(8, 4), dpi=300)
sns.barplot(
    pd.DataFrame(
        simTheater.theater_num_not_entered.values(),
        index=list(simTheater.theater_num_not_entered.keys())
        ).T,
    ax=ax
)
ax.set_ylabel('票房数（人）')
ax.set_xlabel('电影')
fig.suptitle('损失的票房')

""" 仿真结果展示
云边有个洗车店
50
customer 1 arriving at theater at 30.6 s
customer 1 got some tickets: Waited 0.0 s
李焕英：纵横宇宙
40
customer 2 arriving at theater at 40.3 s
customer 1 finished at 45 s
customer 2 got some tickets: Waited 5.5 s
云边有个洗车店
44
customer 3 arriving at theater at 74.1 s
云边有个洗车店
44
customer 4 arriving at theater at 90.6 s
半池绿
100
customer 5 arriving at theater at 98.0 s
天气の爷
75
customer 6 arriving at theater at 99.0 s
半池绿
100
customer 7 arriving at theater at 130.5 s
半池绿
...
customer 353 did not enter the queuing system
芭比海默
0
"""


